/*
 * Copyright 2010 Christian Matzat and others
 *
 *    Licensed under the Apache License, Version 2.0 (the "License");
 *    you may not use this file except in compliance with the License.
 *    You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *    Unless required by applicable law or agreed to in writing, software
 *    distributed under the License is distributed on an "AS IS" BASIS,
 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *    See the License for the specific language governing permissions and
 *    limitations under the License.
 */

package net.matzat.android.hiberdroid.criteria;

import android.database.Cursor;
import android.util.Log;
import net.matzat.android.hiberdroid.Configuration;
import net.matzat.android.hiberdroid.HiberDroidManager;
import net.matzat.android.hiberdroid.exception.MappingNotFoundException;
import net.matzat.android.hiberdroid.exception.NonUniqueResultException;
import net.matzat.android.hiberdroid.exception.PropertyAccessException;
import net.matzat.android.hiberdroid.mapping.ColumnMapping;
import net.matzat.android.hiberdroid.mapping.EntityMapping;
import org.apache.commons.lang.ModStringUtils;

import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

public class Criteria<T> {

    private final static String TAG = Criteria.class.getSimpleName();
    private final static SimpleDateFormat DATE_FORMAT_FULL = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ");
    private final static SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
    private final static SimpleDateFormat DATE_FORMAT_DAY_ONLY = new SimpleDateFormat("yyyy-MM-dd");
    private final static SimpleDateFormat DATE_FORMAT_TIME_ONLY = new SimpleDateFormat("HH:mm:ss");
    private final static SimpleDateFormat DATE_FORMAT_TIME_SHORT_ONLY = new SimpleDateFormat("HH:mm");

    private EntityMapping mapping;
    private Class<T> entityClass;
    private HiberDroidManager dbManager;
    private List<Order> orderList;
    private List<Projection> projectionList;
    private List<Restriction> restrictionList;
    private List<Restriction> havingRestrictionList;

    /**
     * Creates a new Criteria for the given entity class.
     *
     * @param entityClass entity class to query
     */
    public Criteria(Class<T> entityClass) {
        this.mapping = Configuration.findEntityMappingByClass(entityClass);
        this.entityClass = entityClass;
        dbManager = HiberDroidManager.getInstance();
    }

    // Gets a Cursor for a query.
    private Cursor getCursor() {
        String[] columnNames = mapping.getColumnNames();
        // TODO implement LIMIT
        String limit = null;
        boolean distinct = false;
        if (projectionList != null) {
            for (Projection projection : projectionList) {
                if (projection.getProjectionType().equals(Projection.ProjectionType.ROW_COUNT)) {
                    columnNames = new String[]{projection.getProjectionType().getExpression()};
                } else if (projection.getProjectionType().equals(Projection.ProjectionType.DISTINCT)) {
                    distinct = true;
                } else if (projection.getProjectionType().equals(Projection.ProjectionType.LIMIT)) {
                    limit = Integer.toString(projection.getLimitValue());
                }
            }
        }
        return dbManager.getCursor(distinct, mapping.getTable(), columnNames, getParameterSql(restrictionList),
                getParameterValues(restrictionList), getCombinedParameterSql(havingRestrictionList), getGroupSql(), getOrderSql(), limit);
    }

    /**
     * Gets the result of entity objects as List.
     *
     * @return List of objects that match the query
     */
    public List<T> list() {
        List<T> list = new ArrayList<T>();
        Cursor cursor = getCursor();
        try {
            while (cursor.moveToNext()) {
                T entity = entityClass.newInstance();
                fillEntity(cursor, entity);
                list.add(entity);
            }
        } catch (InstantiationException e) {
            Log.e(TAG, "cannot create instance of class " + entityClass);
        } catch (IllegalAccessException e) {
            Log.e(TAG, "cannot create instance of class " + entityClass);
        } finally {
            cursor.close();
        }
        return list;
    }

    /**
     * Gets the result of the query as one entity object.
     *
     * @return
     */
    public T uniqueResult() {
        T entity = null;
        Cursor cursor = getCursor();
        if (cursor.getCount() > 1) {
            throw new NonUniqueResultException(cursor.getCount());
        }
        if (cursor.moveToFirst()) {
            try {
                entity = entityClass.newInstance();
                fillEntity(cursor, entity);
            } catch (InstantiationException e) {
                Log.e(TAG, "cannot create instance of class " + entityClass);
            } catch (IllegalAccessException e) {
                Log.e(TAG, "cannot create instance of class " + entityClass);
            } finally {
                cursor.close();
            }
        }
        return entity;
    }

    // builds the SQL for the ORDER BY clause
    private String getOrderSql() {
        if (orderList == null) return null;
        StringBuffer orderSql = new StringBuffer();
        boolean isFirst = true;
        for (Order order : orderList) {
            if (!isFirst) {
                orderSql.append(", ");
            }
            ColumnMapping columnMapping = mapping.getColumns().get(order.getProperty());
            if (columnMapping == null) {
                throw new MappingNotFoundException(String.format("Mapping for property %s not found.", order.getProperty()));
            }
            orderSql.append(columnMapping.getColumn());
            if (order.isAscending()) {
                orderSql.append(" ASC");
            } else {
                orderSql.append(" DESC");
            }
            isFirst = false;
        }
        return orderSql.toString();
    }

    // builds the SQL for the GROUP BY clause
    private String getGroupSql() {
        if (projectionList == null) return null;
        StringBuffer groupSql = new StringBuffer();
        boolean isFirst = true;
        for (Projection projection : projectionList) {
            if (!isFirst) {
                groupSql.append(", ");
            }
            if (projection.getProjectionType().equals(Projection.ProjectionType.GROUP_PROPERTY)) {
                ColumnMapping columnMapping = mapping.getColumns().get(projection.getProperty());
                if (columnMapping == null) {
                    throw new MappingNotFoundException(String.format("Mapping for property %s not found.", projection.getProperty()));
                }
                groupSql.append(columnMapping.getColumn());
                isFirst = false;
            }
        }
        return groupSql.toString();
    }

    // builds SQL with combined parameters (for HAVING)
    private String getCombinedParameterSql(List<Restriction> restrictions) {
        if (restrictions == null) return null;
        StringBuffer parameterSql = new StringBuffer(getParameterSql(restrictions));
        String[] parameterValues = getParameterValues(restrictions);
        for (String parameterValue : parameterValues) {
            int index = parameterSql.indexOf("?");
            if (index == -1) {
                throw new IndexOutOfBoundsException("Parameter restrictions and values do not match.");
            }
            parameterSql.replace(index, index + 1, parameterValue);
        }
        return parameterSql.toString();
    }

    // builds the parameter SQL for paramter binding
    private String getParameterSql(List<Restriction> restrictions) {
        if (restrictions == null) return null;
        StringBuffer parameterSql = new StringBuffer();
        boolean isFirst = true;
        for (Restriction restriction : restrictions) {
            if (!isFirst) {
                parameterSql.append(" or ");
            }
            parameterSql.append(restriction.getExpression(mapping));
            isFirst = false;
        }
        return parameterSql.toString();
    }

    // builds an object array of paramters
    private String[] getParameterValues(List<Restriction> restrictions) {
        if (restrictions == null) return null;
        List<String> parameterList = new ArrayList<String>(restrictions.size());
        for (Restriction restriction : restrictions) {
            parameterList.add(restriction.getValue());
        }
        return parameterList.toArray(new String[parameterList.size()]);
    }

    private void fillEntity(Cursor cursor, T entity) throws IllegalAccessException {
        for (String property : this.mapping.getColumns().keySet()) {
            ColumnMapping columnMapping = this.mapping.getColumns().get(property);
            int columnIndex = cursor.getColumnIndex(columnMapping.getColumn());
            switch (columnMapping.getColumnType()) {
                case STRING:
                    String stringValue = cursor.getString(columnIndex);
                    setValue(entity, property, columnMapping.getColumnType().getTypeClass(), stringValue);
                    break;
                case LONG:
                    long longValue = cursor.getLong(columnIndex);
                    setValue(entity, property, columnMapping.getColumnType().getTypeClass(), longValue);
                    break;
                case INTEGER:
                    int intValue = cursor.getInt(columnIndex);
                    setValue(entity, property, columnMapping.getColumnType().getTypeClass(), intValue);
                    break;
                case SHORT:
                    int shortValue = cursor.getShort(columnIndex);
                    setValue(entity, property, columnMapping.getColumnType().getTypeClass(), shortValue);
                    break;
                case BYTE:
                    int byteValue = cursor.getInt(columnIndex);
                    setValue(entity, property, columnMapping.getColumnType().getTypeClass(), byteValue);
                    break;
                case DOUBLE:
                    double doubleValue = cursor.getDouble(columnIndex);
                    setValue(entity, property, columnMapping.getColumnType().getTypeClass(), doubleValue);
                    break;
                case FLOAT:
                    float floatValue = cursor.getFloat(columnIndex);
                    setValue(entity, property, columnMapping.getColumnType().getTypeClass(), floatValue);
                    break;
                case DATE:
                    String stringDateValue;
                    Date dateValue = null;
                    stringDateValue = cursor.getString(columnIndex);
                    if (stringDateValue != null) {
                        try {
                            dateValue = DATE_FORMAT_FULL.parse(stringDateValue);
                        } catch (ParseException e0) {
                            try {
                                dateValue = DATE_FORMAT.parse(stringDateValue);
                            } catch (ParseException e1) {
                                try {
                                    dateValue = DATE_FORMAT_DAY_ONLY.parse(stringDateValue);
                                } catch (ParseException e2) {
                                    try {
                                        dateValue = DATE_FORMAT_TIME_ONLY.parse(stringDateValue);
                                    } catch (ParseException e3) {
                                        try {
                                            dateValue = DATE_FORMAT_TIME_SHORT_ONLY.parse(stringDateValue);
                                        } catch (ParseException e4) {
                                            Log.e(TAG, String.format("Date/Time is not parseable: %s", dateValue));
                                            dateValue = null;
                                        }
                                    }
                                }
                            }
                        }
                    }
                    setValue(entity, property, columnMapping.getColumnType().getTypeClass(), dateValue);
                    break;
                case BLOB:
                    byte[] blobValue = cursor.getBlob(columnIndex);
                    setValue(entity, property, columnMapping.getColumnType().getTypeClass(), blobValue);
                    break;
                case BOOLEAN:
                    boolean booleanValue = cursor.getInt(columnIndex) == 1;
                    setValue(entity, property, columnMapping.getColumnType().getTypeClass(), booleanValue);
                    break;

            }
        }
    }

    private void setValue(T entity, String property, Class typeClass, Object value) throws IllegalAccessException {
        try {
            Field field = entityClass.getDeclaredField(property);
            field.setAccessible(true);
            field.set(entity, value);
            return;
        } catch (NoSuchFieldException e) {
            // field is not accessible, go on
        }
        try {
            String methodName = "set" + ModStringUtils.capitalize(property);
            Method method = entityClass.getDeclaredMethod(methodName, typeClass);
            method.setAccessible(true);
            method.invoke(entity, value);
            return;
        } catch (NoSuchMethodException e) {
            // method setProperty is not accessible, go on
        } catch (InvocationTargetException e) {
            // method setProperty is not accessible, go on
        }
        try {
            Method method = entityClass.getDeclaredMethod(property, typeClass);
            method.setAccessible(true);
            method.invoke(entity, value);
            return;
        } catch (NoSuchMethodException e) {
            // method setProperty is not accessible, go on
        } catch (InvocationTargetException e) {
            // method setProperty is not accessible, go on
        }
        throw new PropertyAccessException("Cannot access property to set value.");
    }

    public void addOrder(Order order) {
        if (orderList == null) {
            orderList = new ArrayList<Order>();
        }
        orderList.add(order);
    }

    public void addProjection(Projection projection) {
        if (projectionList == null) {
            projectionList = new ArrayList<Projection>();
        }
        projectionList.add(projection);
    }

    public void add(Restriction restriction) {
        if (restrictionList == null) {
            restrictionList = new ArrayList<Restriction>();
        }
        restrictionList.add(restriction);
    }

    public void addHaving(Restriction havingRestriction) {
        if (havingRestrictionList == null) {
            havingRestrictionList = new ArrayList<Restriction>();
        }
        havingRestrictionList.add(havingRestriction);
    }
}
